/*
 * Copyright (c) 2014 Steve Tracey
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package com.stevetracey.backup.io.google;

import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.zip.CheckedInputStream;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import com.google.api.client.googleapis.media.MediaHttpUploader;
import com.google.api.client.googleapis.services.AbstractGoogleClientRequest;
import com.google.api.client.http.FileContent;
import com.google.api.services.drive.Drive;
import com.google.api.services.drive.Drive.Children;
import com.google.api.services.drive.Drive.Files;
import com.google.api.services.drive.model.ChildList;
import com.google.api.services.drive.model.ChildReference;
import com.google.api.services.drive.model.File;
import com.google.api.services.drive.model.FileList;
import com.google.api.services.drive.model.ParentReference;
import com.google.api.services.drive.model.Property;
import com.google.cloud.Crc32c;
import com.stevetracey.backup.BackupJobContext;

public class GoogleDrive {

	/** Global Drive API client. */
	private static Drive drive;

	/** Cache of remote folders used to increase performance **/
	private static Map<String, File> remoteFolderCache = new HashMap<String, File>();
	
	private static final String MIME_TYPE_FOLDER = "application/vnd.google-apps.folder";

	
	private static Log log = LogFactory.getLog(GoogleDrive.class);

	/**
	 * Initialises the Google Drive for use, required only once globally
	 */
	public static void initialiseGoogleDrive() {

		
		// Set up the global Drive instance
					drive = new Drive.Builder(Google.httpTransport, Google.JSON_FACTORY, Google.CREDENTIAL)
							.setApplicationName(Google.APPLICATION_NAME).build();

	}

	/**
	 * Replaces a remote file with a new version
	 * 
	 * @param pOriginalFile
	 *            - original Google file
	 * @param pNewFile
	 *            - new local File
	 * @return updated Google file
	 */
	public static File replaceFile(File pOriginalFile, java.io.File pNewFile, BackupJobContext pConfig) {
		try {
			log.info("Replacing [" + pNewFile.getName()
					+ "] with newer version");

			FileContent mediaContent = new FileContent("", pNewFile);
			Drive.Files.Update update = drive.files().update(
					pOriginalFile.getId(), pOriginalFile, mediaContent);

			MediaHttpUploader uploader = update.getMediaHttpUploader();
			uploader.setDirectUploadEnabled(false);
			uploader.setProgressListener(new FileUploadProgressListener());

			File returnedFile = (File) executeRequest(update);

			log.debug("File replaced, updating CRC32C...");
			Property property = new Property();
			property.setKey("CRC32C");
			property.setValue(getFileChecksum(pNewFile));

			Drive.Properties.Update updateRequest = drive.properties().update(returnedFile.getId(), "CRC32C", property);

			executeRequest(updateRequest);
			
			pConfig.incrementSuccessfulChangedFileCount();
			pConfig.addSuccessfulChangedFileBytes(pNewFile.length());
			
			log.debug("CRC32C updated succesfully");

			return returnedFile;
		} catch (Exception e) {
			e.printStackTrace();
			log.error("Failure updating file [" + pNewFile.getAbsolutePath()
					+ "]", e);
			pConfig.addFailedFile( pNewFile.getAbsolutePath());
			return null;
		}
	}

	/**
	 * Sets a file property - only for use on new files
	 * 
	 * @param pFile
	 *            - Google file to set property on
	 * @param pKey
	 *            - Key for property
	 * @param pValue
	 *            - Value for property
	 * @return The updated Google File
	 */
	private static File setFileProperty(File pFile, String pKey, String pValue) {
		log.trace("setting file property [" + pKey + "] to [" + pValue + "]");

		Property prop = new Property();
		prop.setKey(pKey);
		prop.setValue(pValue);

		List<Property> properties = new ArrayList<Property>();
		properties.add(prop);

		if (pFile.getProperties() == null) {
			pFile.setProperties(properties);
		} else {
			pFile.getProperties().add(prop);
		}

		return pFile;
	}

	/**
	 * Uploads a new file to Google Drive
	 * 
	 * @param pFile
	 *            - the local File object
	 * @param pDestinationFolder
	 *            - the remote folder to upload to
	 * @return - the inserted Google File
	 */
	private static File uploadFile(java.io.File pFile, File pDestinationFolder, BackupJobContext pConfig) {

		File fileMetadata = new File();
		fileMetadata.setTitle(pFile.getName());
		setFileProperty(fileMetadata, "CRC32C", getFileChecksum(pFile));

		List<ParentReference> parents = new ArrayList<ParentReference>();
		ParentReference parentRef = new ParentReference();
		parentRef.setId(pDestinationFolder.getId());
		parents.add(parentRef);
		fileMetadata.setParents(parents);

		FileContent mediaContent = new FileContent("", pFile);

		try {
			Drive.Files.Insert insert = drive.files().insert(fileMetadata,
					mediaContent);
			MediaHttpUploader uploader = insert.getMediaHttpUploader();
			uploader.setDirectUploadEnabled(false);
			uploader.setProgressListener(new FileUploadProgressListener());
			File result = (File) executeRequest(insert);
			pConfig.incrementSuccessfulNewFileCount();
			pConfig.addSuccessfulNewFileBytes(pFile.length());
			log.info("Uploaded new file [" + pFile.getAbsolutePath() + "] to ["
					+ pDestinationFolder.getTitle() + "]");
			return result;
		} catch (Exception e) {
			e.printStackTrace();
			log.error("Failure uploading file [" + pFile.getAbsolutePath()
					+ "]", e);
			pConfig.addFailedFile(pFile.getAbsolutePath());
			return null;
		}
	}

	/**
	 * Get all files in a remote folder
	 * 
	 * @param pFolderId
	 *            The Id of the Google folder
	 * @param pRecursive
	 *            - whether to get all files recursively
	 * @return An Array of Google files
	 */
	private static File[] getFilesinFolder(String pFolderId, boolean pRecursive) {

		log.trace("Listing files in folder [" + pFolderId + "] recursive=" + pRecursive);
		List<File> results = new ArrayList<File>();

		try {
			Children.List childrenRequest = drive.children().list(pFolderId);
			ChildList children = (ChildList) executeRequest(childrenRequest);
			
			for (ChildReference child : children.getItems()) {

				Files.Get fileRequest = drive.files().get(child.getId());
				
				File file = (File) executeRequest(fileRequest);
				
				results.add(file);
				if (pRecursive && (file.getMimeType().equals(MIME_TYPE_FOLDER))) {
					results.addAll(Arrays.asList(getFilesinFolder(file.getId(),
							true)));
				}
			}
		} catch (Exception e) {
			e.printStackTrace();
			log.error("Failure getting files in folder [" + pFolderId + "]", e);
		}
		return results.toArray(new File[results.size()]);
	}
	
	private static File getParent(File pFile)
	{
		ParentReference parentRef = pFile.getParents().get(0);
		try {
			return drive.files().get(parentRef.getId()).execute();
		} catch (IOException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
			return null;
		}
	}

	/**
	 * Gets a remote folder by path
	 * 
	 * @param pFolderPath
	 *            - The path to the folder from the root
	 * @param pCreateFolders
	 *            - whether to create folders if they don't already exist
	 * @return The Google file representing the folder
	 */
	private static File getFolderByPath(String pFolderPath,
			boolean pCreateFolders) {

		log.trace("Getting folder by path [" + pFolderPath
				+ "] with create folders=" + pCreateFolders);

		if (pFolderPath.startsWith("/"))
			pFolderPath = pFolderPath.substring(1);

		if (remoteFolderCache.containsKey(pFolderPath)) {
			log.debug("Using cached folder for:" + pFolderPath);
			return remoteFolderCache.get(pFolderPath);
		}

		String[] folders = pFolderPath.split("/");

		File[] currentFolderFiles = getFilesinFolder("root", false);

		File currentFolder = null;
		File parentFolder = null;

		for (String folderName : folders) {
			parentFolder = currentFolder;
			currentFolder = null;
			for (File file : currentFolderFiles) {
				if (file.getTitle().equals(folderName)) {
					currentFolderFiles = getFilesinFolder(file.getId(), false);
					currentFolder = file;
					break;
				}
			}
			if (currentFolder == null) {
				if (!pCreateFolders)
					return null;
				// create folders
				if (parentFolder == null) {
					currentFolder = createFolder(folderName, "root");
				} else {
					currentFolder = createFolder(folderName,
							parentFolder.getId());
				}

			}

		}
		remoteFolderCache.put(pFolderPath, currentFolder);
		log.debug("Added remote folder to cache [" + pFolderPath + "]");
		return currentFolder;
	}

	/**
	 * Gets a Google File by pathname from the root folder
	 * 
	 * @param pPath
	 * @return
	 */
	private static File getFileByPath(String pPath) throws IOException {

		log.trace("Getting file by path [" + pPath + "]");

		String remoteFile = pPath.substring(pPath.lastIndexOf("/") + 1);
		String remotePath = pPath.substring(0, pPath.lastIndexOf("/"));

		File parentFolder = getFolderByPath(remotePath, true);

		try {
			Children.List request = drive.children().list(parentFolder.getId());

			ChildList children = (ChildList) executeRequest(request);

			for (ChildReference child : children.getItems()) {

				Files.Get fileRequest =  drive.files().get(child.getId());
				File file = (File) executeRequest(fileRequest);

				if (file.getTitle().equals(remoteFile))
					return file;
			}
		} catch (NullPointerException e) {
			e.printStackTrace();
			log.error("Failure getting file by path", e);
			throw new IOException("Failed to get file by path");
		}
		return null;
	}

	/**
	 * Creates a folder in Google drive
	 * 
	 * @param pName
	 *            - Name of folder to create
	 * @param pParentId
	 *            - Parent ID of folder to create within
	 * @return
	 */
	private static File createFolder(String pName, String pParentId) {

		log.debug("Creating folder [" + pName + "]");

		File body = new File();
		body.setTitle(pName);
		body.setMimeType(MIME_TYPE_FOLDER);
		List<ParentReference> parents = new ArrayList<ParentReference>();
		ParentReference parentRef = new ParentReference();
		parentRef.setId(pParentId);
		parents.add(parentRef);

		body.setParents(parents);

		try {
			Files.Insert insertRequest = drive.files().insert(body);
			File file = (File) executeRequest(insertRequest);
			return file;
		} catch (Exception e) {
			e.printStackTrace();
			log.error("Failure creating folder [" + pName + "]", e);
			return null;
		}
	}
	
	@SuppressWarnings("rawtypes")
	private static Object executeRequest(AbstractGoogleClientRequest pRequest) throws IOException
	{
		int delay = 1000;
		for (int i=0 ; i<10; i++)
		{
			try {
				if (i > 0) log.warn("Re-trying now...[attempt " + (i+1) + " of 10]");
				Object response = pRequest.execute();
				if (i > 0) log.warn("Re-try was successful");
				return response;
			} catch (IOException e) {
				log.warn("Request failed, backing off for " + delay + " ms then retring...", e);
			}
			
			try {
				
				Thread.sleep(delay);
				delay = (int) delay * 3 / 2;
				delay+= (int) (new Random().nextDouble() * 1000);
			} catch (InterruptedException e) {
				// 
			}
		}
		throw new IOException("Failed to execute request");
	}

	/**
	 * Get a CRC32C file checksum for a local file
	 * 
	 * @param pFile
	 *            - Local file
	 * @return CRC32 checksum
	 */
	private static String getFileChecksum(java.io.File pFile) {

		log.trace("Generating CRC32C for [" + pFile.getAbsolutePath() + "]");

		FileInputStream file;

		try {
			file = new FileInputStream(pFile);
			CheckedInputStream check = new CheckedInputStream(file,
					new Crc32c());
			BufferedInputStream in = new BufferedInputStream(check);
			while (in.read() != -1) {
				// Read file in completely
			}
			file.close();
			return check.getChecksum().getValue() + "";
		} catch (Exception e) 
		{
			e.printStackTrace();
			log.error(
					"Failure generating CRC32C for [" + pFile.getAbsolutePath()
							+ "]", e);
			return null;
		}
	}

	/**
	 * Compare two files by CRC
	 * 
	 * @param pGoogleFile
	 * @param pFile
	 * @return
	 */
	private static boolean areFilesEqual(File pGoogleFile, java.io.File pFile) {

		log.trace("Comparing two files by CRC32C [" + pGoogleFile.getTitle()
				+ "] and [" + pFile.getAbsolutePath() + "]");

		// add check to local database for CRC
		String localCrc = null;
		String remoteCrc = null;

		// if (localCrc == null)
		localCrc = getFileChecksum(pFile);

		if (pGoogleFile.getProperties() == null) return false;
		
		for (Property prop : pGoogleFile.getProperties()) {
			if (prop.getKey().equals("CRC32C"))
				remoteCrc = prop.getValue();
		}

		return (localCrc != null && remoteCrc != null && localCrc
				.equals(remoteCrc));

	}

	/**
	 * Synchronise all folder changes to Google drive. New and changed files
	 * only, will not delete files
	 * 
	 * @param pLocalFolder
	 * @param pRemoteFolder
	 */
	private static void syncFolders(BackupJobContext pConfig, String pSubPath) throws IOException {

		log.debug(pConfig);
		log.debug("pSubPath=" + pSubPath);
		log.debug("Syncing folder [" + pConfig.getLocalRoot() + 
				pSubPath + 
				"] to [" + pConfig.getRemoteRoot() + 
				pSubPath +
				"]");

		java.io.File localFolder = new java.io.File(pConfig.getLocalRoot() + 
				pSubPath);

		java.io.File[] localFiles = localFolder.listFiles();

		for (java.io.File file : localFiles) {
			String remotePath = pConfig.getRemoteRoot() + 
					pSubPath + "/" + file.getName();
			String pLocalPath = pConfig.getLocalRoot() + 
					pSubPath + "/" + file.getName();

			log.debug("processing [" + pLocalPath + "]");

			if (file.isDirectory()) {
				// create new remote folder if not existing
				try
				{
					GoogleDrive.getFolderByPath(remotePath, true);
					System.out.println("HERE");
					log.debug("Recursing sync with subPath=" + pSubPath);
					syncFolders(pConfig, pSubPath + "/" + file.getName());
					
				}
				catch (IOException e)
				{
					log.error("Failure syncing folders",e);
				}
			} else {
				try
				{
					syncFile(file, pConfig);
				}
				catch (IOException e)
				{
					log.error("Failure Syncing folder [" + pConfig.getLocalRoot() + 
							pSubPath + 
							"] to [" + pConfig.getRemoteRoot() + 
							pSubPath +
							"]");
					log.error("This folder will be skipped and attempted at the next scheduled backup",e);
				}
			}
		}
		//pConfig.incrementSuccessfulFolderCount();
	}
	
	public static void syncFolders(BackupJobContext pConfig) throws IOException
	{
		syncFolders(pConfig, "/" + pConfig.getFolderName());
	}

	/**
	 * Synchronise changes to a file to Google drive. New and changed files
	 * only, will not delete files
	 * 
	 * @param pLocalFile
	 * @param pRemotePath
	 */
	public static synchronized void syncFile(java.io.File pLocalFile,
			BackupJobContext pConfig) throws IOException {

		String remoteFilePath = pConfig.getRemotePath(pLocalFile.getAbsolutePath());
		
		log.debug("Syncing file [" + pLocalFile.getAbsolutePath() + "] to ["
				+ remoteFilePath + "]");

		File remoteFile = GoogleDrive.getFileByPath(remoteFilePath);

		if (remoteFile == null) // does not exist, upload
		{
			log.debug("File [" + pLocalFile.getName()
					+ "] not found in remote location, adding new file");
			File parentFolder = GoogleDrive.getFolderByPath(
					remoteFilePath.substring(0, remoteFilePath.lastIndexOf("/")),
					true);
			GoogleDrive.uploadFile(pLocalFile, parentFolder, pConfig);
		} else {
			// check CRC
			if (!GoogleDrive.areFilesEqual(remoteFile, pLocalFile)) {
				// upload file
				log.debug("File [" + pLocalFile.getName()
						+ "] new version detected, uploading new file");
				GoogleDrive.replaceFile(remoteFile, pLocalFile, pConfig);
			} else {
				// files are equal
				log.debug("File [" + pLocalFile.getName()
						+ "] no changes detected, skipping");
			}
		}
	}

	public static Map<String, Integer> getRemoteFileAndFolderCounts(String pRemoteFolderPath)
	{
		File remoteFolder = getFolderByPath(pRemoteFolderPath, true);
		File[] files = getFilesinFolder(remoteFolder.getId(), true);
		int fileCount = 0;
		int folderCount = 1;
		for (File file : files)
		{
			if (file.getMimeType().equals(MIME_TYPE_FOLDER))
			{
				folderCount++;
			}
			else
			{
				fileCount++;
			}
		}
	
	
	Map<String, Integer> results = new HashMap<String, Integer>();
	
	results.put("FOLDER_COUNT", new Integer(folderCount));
	results.put("FILE_COUNT", new Integer(fileCount));
	
	return results;
	}
	
}
